call和ret命令

call main

call和ret是重要的控制转移类指令,本文将详细分析这两个指令的执行过程,从而理清C语言在调用函数过程中堆栈和寄存器的变化。

call和ret的原理

函数调用命令,使用方式为:

1
call addr

等价于

1
2
pushl %eip          # 先将当前eip保存
movl $addr, %eip # 然后将新地址移至eip

所以每次调用call,会导致esp减小,因为要将返回地址进行保存。x86架构规定栈向下生长,一个使用call指令的例子如下:

Example instruction What it does
call 0x12345 pushl %eip
movl $0x12345, %eip
ret popl %eip
ret

函数返回命令,使用方式为:

1
ret

该指令等价于

1
popl %eip

调用函数的过程

下面我们以实际的调用函数的过程为例,对call和ret调用过程中堆栈和寄存器的变化进行研究。我们首先考虑无参函数,进一步扩展至有参函数。

调用无参函数的过程

考虑一个最简单的函数调用过程如下:

1
2
3
4
5
6
7
8
9
10
// in main.c

int function1(){
return 0;
}

int main(){
function1();
return 0;
}

使用下述命令对main.c进行编译:

1
gcc -o build main.c -g

输入gdb进入调试界面,然后输入下面的命令载入符号表:

1
2
(gdb) file build
Reading symbols from build...done.

载入后,启动tui调试界面,然后开启汇编和寄存器窗口:

1
2
3
(gdb) tui enable
(gdb) layout asm
(gdb) layout reg

根据汇编代码,我们可以在程序的第一条指令处设置断点。

1
(gdb) starti

下面我们将根据调试过程,将函数调用过程分为五个阶段,对每个阶段中栈及关键寄存器的变化进行介绍。

函数调用之前:原始栈

在调用函数之前,我们先观察调用函数的堆栈情况,根据调试结果,堆栈的结构如下:

uProf界面

此时,eip恰好位于call function1之前。

使用call命令:保存eip

调用call命令后,该命令会首先对eip进行保存,将其push到栈内,然后将新命令的地址保存至eip中。经过该操作后,栈的结构如下:

uProf界面

执行函数:建立新栈

在调用call命令后,我们进入被调用函数中,开始执行被调用函数。在被调用函数中,第一个工作就是创建新栈,命令如下:

1
2
push %ebp
mov %esp, %ebp

将旧的栈基址保存,然后将新栈基址设置为当前esp,经过该操作后,新的栈生成:

uProf界面

即将从函数返回:销毁新栈

在函数调用过程中,栈也会随着局部变量的添加而增长,这里我们不关心栈中局部变量的创建和销毁过程,只关注被调用函数返回的前一时刻,我们需要对被调用函数的栈进行销毁,命令如下:

1
pop %ebp

uProf界面

现在,esp寄存器指向了保存旧的eip的位置,被调用函数的堆栈已经销毁,程序只需要将eip弹出,即可恢复调用程序的堆栈,同时从调用函数中中断的位置继续执行。

使用ret命令:恢复eip

最后,被调用函数执行ret指令,将eip从堆栈中恢复,实际就是pop %eip,在该操作之后,堆栈的结构如下:

uProf界面

在执行完成上面的命令之后,两个堆栈之间用于保存eip的这一小段空间也被销毁,现在程序状态已经回到了调用函数之前,将会从被中断的地方继续执行。

调用有参函数的过程

参考文献

0%